]> dgit.raspbian.org Git - nodejs.git/commitdiff
http2: cap originSet size to prevent unbounded memory growth
authorMatteo Collina <hello@matteocollina.com>
Sun, 19 Apr 2026 14:48:43 +0000 (16:48 +0200)
committerBastien Roucariès <rouca@debian.org>
Sun, 16 Aug 2026 17:34:06 +0000 (19:34 +0200)
A malicious HTTP/2 server can send repeated ORIGIN frames with unique
origins, causing unbounded growth of the client-side originSet for the
lifetime of the session. Cap the set at 128 entries; once full, new
origins from ORIGIN frames are silently dropped.

Ref: https://hackerone.com/reports/3676863
PR-URL: https://github.com/nodejs-private/node-private/pull/855
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
CVE-ID: CVE-2026-48619
Refs: https://hackerone.com/reports/3676863
origin: backport, https://github.com/nodejs/node/commit/c79968e108002c2394bdb9e9cefb2c8c8cc202f8

Gbp-Pq: Topic sec
Gbp-Pq: Name CVE-2026-48619.patch

doc/api/errors.md
doc/api/http2.md
lib/internal/errors.js
lib/internal/http2/core.js
test/parallel/test-http2-origin-set-max-size.mjs [new file with mode: 0644]

index c657814b05821094227c8fe2ee5c80b7c26f4771..3a07f4a454aef56e4aad597428d1a205a8a15065 100644 (file)
@@ -1724,6 +1724,17 @@ added: v15.14.0
 The limit of acceptable invalid HTTP/2 protocol frames sent by the peer,
 as specified through the `maxSessionInvalidFrames` option, has been exceeded.
 
+<a id="ERR_HTTP2_TOO_MANY_ORIGINS"></a>
+
+### `ERR_HTTP2_TOO_MANY_ORIGINS`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+The number of uniq origin sent by the server has exceeded the value defined in
+`options.maxOriginSetSize`.
+
 <a id="ERR_HTTP2_TRAILERS_ALREADY_SENT"></a>
 
 ### `ERR_HTTP2_TRAILERS_ALREADY_SENT`
index 518990a2e9cb8ce15082150162e73957cca6b7ad..705644d5c742d0f6f70fb31d4617b19a27a74654 100644 (file)
@@ -2719,6 +2719,8 @@ changes:
     This is similar to [`server.maxHeadersCount`][] or
     [`request.maxHeadersCount`][] in the `node:http` module. The minimum value
     is `1`. **Default:** `128`.
+  * `maxOriginSetSize` {number} Sets the maximum number of uniq origin the sever
+    can send via ORIGIN frames. **Default:** `128`.
   * `maxOutstandingPings` {number} Sets the maximum number of outstanding,
     unacknowledged pings. **Default:** `10`.
   * `maxReservedRemoteStreams` {number} Sets the maximum number of reserved push
index 6e1974b1e642b39553ecbfc57e38688b6dce9ddc..9ad8d769568e136d700c9211490a1df0620d380e 100644 (file)
@@ -1154,6 +1154,8 @@ E('ERR_HTTP2_STREAM_ERROR', 'Stream closed with error code %s', Error);
 E('ERR_HTTP2_STREAM_SELF_DEPENDENCY',
   'A stream cannot depend on itself', Error);
 E('ERR_HTTP2_TOO_MANY_INVALID_FRAMES', 'Too many invalid HTTP/2 frames', Error);
+E('ERR_HTTP2_TOO_MANY_ORIGINS',
+  'The server sent more ORIGIN frames than the allowed number of %s', Error);
 E('ERR_HTTP2_TRAILERS_ALREADY_SENT',
   'Trailing headers have already been sent', Error);
 E('ERR_HTTP2_TRAILERS_NOT_READY',
index 38844d30f308cb285d76cf6b17e71f60a96dedb9..9ba39d49ddb4a203a5170aa77b393e80715c3ad5 100644 (file)
@@ -105,6 +105,7 @@ const {
     ERR_HTTP2_STREAM_CANCEL,
     ERR_HTTP2_STREAM_ERROR,
     ERR_HTTP2_STREAM_SELF_DEPENDENCY,
+    ERR_HTTP2_TOO_MANY_ORIGINS,
     ERR_HTTP2_TRAILERS_ALREADY_SENT,
     ERR_HTTP2_TRAILERS_NOT_READY,
     ERR_HTTP2_UNSUPPORTED_PROTOCOL,
@@ -235,6 +236,7 @@ const kInit = Symbol('init');
 const kInfoHeaders = Symbol('sent-info-headers');
 const kLocalSettings = Symbol('local-settings');
 const kNativeFields = Symbol('kNativeFields');
+const kMaxOriginSetSize = Symbol('max-ORIGIN-set-size');
 const kOptions = Symbol('options');
 const kOwner = owner_symbol;
 const kOrigin = Symbol('origin');
@@ -661,8 +663,13 @@ function onOrigin(origins) {
   if (!session.encrypted || session.destroyed)
     return undefined;
   const originSet = initOriginSet(session);
-  for (let n = 0; n < origins.length; n++)
+  for (let n = 0; n < origins.length; n++) {
+    if (originSet.size >= session[kMaxOriginSetSize]) {
+      session.destroy(new ERR_HTTP2_TOO_MANY_ORIGINS(session[kMaxOriginSetSize]));
+      return;
+    }
     originSet.add(origins[n]);
+  }
   session.emit('origin', origins);
 }
 
@@ -3276,6 +3283,13 @@ function connect(authority, options, listener) {
   assertIsObject(options, 'options');
   options = { ...options };
 
+  let { maxOriginSetSize } = options;
+  if (maxOriginSetSize != null) {
+    validateNumber(maxOriginSetSize, 'options.maxOriginSetSize', 0);
+  } else {
+    maxOriginSetSize = 128;
+  }
+
   if (typeof authority === 'string')
     authority = new URL(authority);
 
@@ -3315,6 +3329,7 @@ function connect(authority, options, listener) {
 
   session[kAuthority] = `${options.servername || host}:${port}`;
   session[kProtocol] = protocol;
+  session[kMaxOriginSetSize] = maxOriginSetSize;
 
   if (typeof listener === 'function')
     session.once('connect', listener);
diff --git a/test/parallel/test-http2-origin-set-max-size.mjs b/test/parallel/test-http2-origin-set-max-size.mjs
new file mode 100644 (file)
index 0000000..08bdf1e
--- /dev/null
@@ -0,0 +1,110 @@
+import {
+  expectsError,
+  hasCrypto,
+  mustCall,
+  mustNotCall,
+  mustSucceed,
+  skip,
+} from '../common/index.mjs';
+import * as fixtures from '../common/fixtures.mjs';
+import assert from 'node:assert';
+
+if (!hasCrypto)
+  skip('missing crypto');
+
+const {
+  createSecureServer,
+  connect,
+} = await import('node:http2');
+
+const key = fixtures.readKey('agent8-key.pem', 'binary');
+const cert = fixtures.readKey('agent8-cert.pem', 'binary');
+const ca = fixtures.readKey('fake-startcom-root-cert.pem', 'binary');
+
+const server = createSecureServer({ key, cert });
+server.on('stream', (stream) => {
+  stream.respond();
+  stream.end('ok');
+});
+server.on('session', (session) => {
+  let i = 0;
+  const timer = setInterval(() => {
+    try {
+      session.origin(...Array.from({ length: 10 }, () => `https://o${i++}.example.com`));
+    } catch {
+      clearInterval(timer);
+    }
+  }, 10);
+
+  session.on('close', () => {
+    clearInterval(timer);
+  });
+});
+
+await new Promise((resolve) => server.listen(0, resolve));
+
+// Test fantasist values
+[Symbol(), '0', 1n, {}, [], true, false, /s/, () => {}].forEach((maxOriginSetSize) => {
+  assert.throws(
+    () => connect(`https://localhost:${server.address().port}`, { ca, maxOriginSetSize }),
+    { code: 'ERR_INVALID_ARG_TYPE' },
+  );
+});
+[NaN, -1].forEach((maxOriginSetSize) => {
+  assert.throws(
+    () => connect(`https://localhost:${server.address().port}`, { ca, maxOriginSetSize }),
+    { code: 'ERR_OUT_OF_RANGE' },
+  );
+});
+
+await new Promise((resolve) => server.getConnections(mustSucceed((count) => {
+  assert.strictEqual(count, 0);
+  resolve();
+})));
+
+// Test default value
+await new Promise((resolve) => {
+  const client = connect(`https://localhost:${server.address().port}`, { ca });
+
+  client.on('origin', mustCall(12)); // Default value is 128, the first 12 frames should pass, the 13th one should error
+  client.on('error', expectsError({
+    code: 'ERR_HTTP2_TOO_MANY_ORIGINS',
+  }));
+  client.on('goaway', mustNotCall());
+  client.on('close', resolve);
+
+  client.request().resume();
+});
+
+// Test non-default values
+await Promise.all([-0, 9, 1.5].map((maxOriginSetSize) => new Promise((resolve) => {
+  const client = connect(`https://localhost:${server.address().port}`, { ca, maxOriginSetSize });
+
+  client.on('origin', mustNotCall()); // The server send 10 origins on the first frame, that's already too many.
+  client.on('error', expectsError({
+    code: 'ERR_HTTP2_TOO_MANY_ORIGINS',
+  }));
+  client.on('goaway', mustNotCall());
+  client.on('close', resolve);
+
+  client.request().resume();
+})));
+
+
+// Test values higher than the default value
+await Promise.all([512, Infinity].map((maxOriginSetSize) => new Promise((resolve) => {
+  const client = connect(`https://localhost:${server.address().port}`, { ca, maxOriginSetSize });
+
+  client.on('origin', mustCall(() => {
+    if (client.originSet.length > 128) {
+      client.destroy();
+    }
+  }, 13));
+  client.on('error', mustNotCall());
+  client.on('goaway', mustNotCall());
+  client.on('close', resolve);
+
+  client.request().resume();
+})));
+
+server.close();